/* Noirparts.com storefront — vanilla JS SPA driven by /data/*.json (exported from the NoirParts_Master sheet) */ const DATA_URLS = { categories: '/web/content/1965/np_categories.json', products: '/web/content/1970/np_products.json', vehicles: '/web/content/1967/np_vehicles.json', compatByMod: '/web/content/1968/np_compat_by_mod.json', compatByProduct: '/web/content/1969/np_compat_by_product.json', }; const state = { categories: [], products: [], productsById: {}, categoriesById: {}, vehicles: [], compatByMod: {}, compatByProduct: {}, cart: loadCart(), selectedVehicle: loadVehicle(), // {makeName, modelLineName, modelName, mod:{id,name,engine,...}} loaded: false, }; const CURRENCY = '₹'; const MAKE_ACRONYMS = new Set(['BMW','BYD','VW']); function cleanProductName(name){ if(!name || !name.includes(';')) return name; const parts = name.split(';').map(s => s.trim()); if(parts.length !== 2 || !parts[0] || !parts[1]) return name.replace(/;\s*/g, ' - ').trim(); let [base, axle] = parts; const sideMatch = base.match(/\s+(Left|Right)$/i); if(sideMatch){ const side = sideMatch[1]; base = base.slice(0, sideMatch.index).trim(); return `${axle} ${side} ${base}`; } return `${axle} ${base}`; } const SITE_TITLE_SUFFIX = ' | NoirParts'; const DEFAULT_META_DESCRIPTION = 'Shop 1800+ verified brake pads, discs, drums, calipers, cylinders and more from Bosch, Brembo, Ferodo, HELLA and other trusted brands. Instant fitment check by make, model and engine. COD available across India.'; function setPageMeta(title, description){ document.title = title ? `${title}${SITE_TITLE_SUFFIX}` : 'NoirParts — Genuine Brake Parts for 2700+ Indian Vehicles'; let m = document.querySelector('meta[name="description"]'); if(!m){ m = document.createElement('meta'); m.setAttribute('name','description'); document.head.appendChild(m); } m.setAttribute('content', description || DEFAULT_META_DESCRIPTION); } function updateProductJsonLd(ld){ let el = document.getElementById('productJsonLd'); if(!ld){ if(el) el.remove(); return; } if(!el){ el = document.createElement('script'); el.type = 'application/ld+json'; el.id = 'productJsonLd'; document.head.appendChild(el); } el.textContent = JSON.stringify(ld); } function properMake(name){ const str = (name==null?'':String(name)); if (MAKE_ACRONYMS.has(str.toUpperCase())) return str.toUpperCase(); return str.toLowerCase().replace(/(^|[\s-])([a-z])/g, (m, sep, ch) => sep + ch.toUpperCase()); } function fixOrdinals(name){ const str = (name==null?'':String(name)); return str.replace(/(\d+)(st|nd|rd|th)\b/gi, (m, num, suf) => num + suf.toLowerCase()); } const PLACEHOLDER_SVG = `
Image coming soon
`; function productPlaceholder(p){ const cat = p && state.categoriesById && state.categoriesById[p.category_id]; const icon = cat ? categoryIcon(cat.name) : CAT_ICONS.default; const label = cat ? esc(cat.name) : 'Photo coming soon'; return `
${icon}
${label}
`; } /* ---------------- persistence ---------------- */ function loadCart(){ try{ return JSON.parse(localStorage.getItem('np_cart')||'[]'); }catch(e){ return []; } } function saveCart(){ localStorage.setItem('np_cart', JSON.stringify(state.cart)); updateCartCount(); } function loadVehicle(){ try{ return JSON.parse(localStorage.getItem('np_vehicle')||'null'); }catch(e){ return null; } } function saveVehicle(v){ state.selectedVehicle = v; if(v) localStorage.setItem('np_vehicle', JSON.stringify(v)); else localStorage.removeItem('np_vehicle'); renderVehiclePickerLabel(); } /* ---------------- boot ---------------- */ async function boot(){ const [categories, products, vehicles, compatByMod, compatByProduct] = await Promise.all( Object.values(DATA_URLS).map(u => fetch(u).then(r => r.json())) ); state.categories = categories; state.products = products; products.forEach(p => { p.name = cleanProductName(p.name); }); state.vehicles = vehicles; state.compatByMod = compatByMod; state.compatByProduct = compatByProduct; state.productsById = Object.fromEntries(products.map(p => [p.id, p])); state.categoriesById = Object.fromEntries(categories.map(c => [c.id, c])); state.loaded = true; renderCatNav(); renderFooter(); populateVehicleMakes(); renderVehiclePickerLabel(); updateCartCount(); document.getElementById('year').textContent = new Date().getFullYear(); window.addEventListener('hashchange', route); route(); } /* ---------------- helpers ---------------- */ function leafCategories(){ return state.categories.filter(c => c.leaf); } function topCategory(){ // Prefer the level-1 category that actually has products under it (the catalog may only // cover a subset of the full Category_Master taxonomy), falling back to the first level-1 // category if none have products yet. const l1 = state.categories.filter(c => c.level === 1); let best = null, bestCount = -1; l1.forEach(c => { const n = productCountInScope(c.id); if(n > bestCount){ best = c; bestCount = n; } }); return best || l1[0]; } function catChildren(id){ return state.categories.filter(c => c.parent === id).sort((a,b) => (a.order||0)-(b.order||0)); } function catBreadcrumb(id){ const chain = []; let cur = state.categoriesById[id]; while(cur){ chain.unshift(cur); cur = cur.parent ? state.categoriesById[cur.parent] : null; } return chain; } /* Categories that actually have products directly assigned — used for nav & homepage tiles so the site adapts to however deep/shallow the category tree is, instead of assuming a fixed level. */ /* All descendant category ids (including itself) for a given category. */ function categoryScopeIds(catId){ const ids = new Set([catId]); let frontier = [catId]; while(frontier.length){ const next = []; state.categories.forEach(c => { if(c.parent && frontier.includes(c.parent)){ ids.add(c.id); next.push(c.id); } }); frontier = next; } return ids; } /* Product count for a category INCLUDING all of its descendants. */ function productCountInScope(catId){ const ids = categoryScopeIds(catId); let n = 0; for(let i=0;i productCountInScope(c.id) > 0); } function imgFallback(imgEl, productId){ if(imgEl && imgEl.parentElement) imgEl.parentElement.innerHTML = productPlaceholder(productId ? state.productsById[productId] : null); } function fmtPrice(n){ return CURRENCY + Number(n).toLocaleString('en-IN'); } function discountPct(price, mrp){ if(!mrp || mrp <= price) return 0; return Math.round((1 - price/mrp) * 100); } function esc(s){ return (s==null?'':String(s)).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function qs(sel, root=document){ return root.querySelector(sel); } function qsa(sel, root=document){ return Array.from(root.querySelectorAll(sel)); } function showToast(msg){ const t = document.getElementById('toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(showToast._tm); showToast._tm = setTimeout(() => t.classList.remove('show'), 2200); } /* ---------------- category nav / footer ---------------- */ function renderCatNav(){ const nav = document.getElementById('catNav'); const cats = shoppableTopCategories(); nav.innerHTML = `All Products` + cats.map(c => `${esc(c.name)}`).join(''); } function renderFooter(){ const leaves = leafCategories().filter(c => productCountInScope(c.id) > 0).slice(0, 6); document.getElementById('footerCats').innerHTML = leaves.map(c => `
  • ${esc(c.name)}
  • `).join(''); const brands = [...new Set(state.products.map(p => p.brand))].sort(); document.getElementById('footerBrands').innerHTML = brands.map(b => `
  • ${esc(b)}
  • `).join(''); } /* ---------------- vehicle picker ---------------- */ function populateVehicleMakes(){ const sel = document.getElementById('vpMake'); sel.innerHTML = `` + [...state.vehicles].sort((a,b) => properMake(a.name).localeCompare(properMake(b.name))).map(m => ``).join(''); } function renderVehiclePickerLabel(){ const label = document.getElementById('vehiclePickerLabel'); if (!label) return; const v = state.selectedVehicle; label.textContent = v ? `${properMake(v.makeName)} ${fixOrdinals(v.modelName)} · ${v.mod.name}` : 'Select your vehicle'; } function wireVehiclePicker(){ const modal = document.getElementById('vehicleModal'); const openBtn = document.getElementById('vehiclePickerBtn'); const closeBtn = document.getElementById('vehicleModalClose'); const makeSel = document.getElementById('vpMake'); const lineSel = document.getElementById('vpModelLine'); const modelSel = document.getElementById('vpModel'); const modSel = document.getElementById('vpMod'); const submitBtn = document.getElementById('vpSubmit'); const clearBtn = document.getElementById('vpClear'); if (openBtn) openBtn.onclick = () => modal.classList.add('open'); closeBtn.onclick = () => modal.classList.remove('open'); modal.onclick = (e) => { if(e.target === modal) modal.classList.remove('open'); }; makeSel.onchange = () => { const make = state.vehicles.find(m => m.name === makeSel.value); lineSel.innerHTML = `` + (make ? [...make.model_lines].sort((a,b) => fixOrdinals(a.name).localeCompare(fixOrdinals(b.name))).map(l => ``).join('') : ''); lineSel.disabled = !make; modelSel.innerHTML = ``; modelSel.disabled = true; modSel.innerHTML = ``; modSel.disabled = true; submitBtn.disabled = true; }; lineSel.onchange = () => { const make = state.vehicles.find(m => m.name === makeSel.value); const line = make && make.model_lines.find(l => l.name === lineSel.value); modelSel.innerHTML = `` + (line ? [...line.models].sort((a,b) => fixOrdinals(a.name).localeCompare(fixOrdinals(b.name))).map(md => ``).join('') : ''); modelSel.disabled = !line; modSel.innerHTML = ``; modSel.disabled = true; submitBtn.disabled = true; }; modelSel.onchange = () => { const make = state.vehicles.find(m => m.name === makeSel.value); const line = make && make.model_lines.find(l => l.name === lineSel.value); const model = line && line.models.find(md => md.name === modelSel.value); modSel.innerHTML = `` + (model ? [...model.modifications].sort((a,b) => fixOrdinals(a.name).localeCompare(fixOrdinals(b.name))).map(md => ``).join('') : ''); modSel.disabled = !model; submitBtn.disabled = true; }; modSel.onchange = () => { submitBtn.disabled = !modSel.value; }; submitBtn.onclick = () => { const make = state.vehicles.find(m => m.name === makeSel.value); const line = make.model_lines.find(l => l.name === lineSel.value); const model = line.models.find(md => md.name === modelSel.value); const mod = model.modifications.find(md => md.id === modSel.value); saveVehicle({ makeName: make.name, modelLineName: line.name, modelName: model.name, mod }); modal.classList.remove('open'); location.hash = `#/vehicle/${mod.id}`; }; clearBtn.onclick = () => { saveVehicle(null); modal.classList.remove('open'); }; } function wireSidebarVehiclePicker(){ const makeSel = document.getElementById('lsMake'); if(!makeSel) return; const lineSel = document.getElementById('lsModelLine'); const modelSel = document.getElementById('lsModel'); const modSel = document.getElementById('lsMod'); const submitBtn = document.getElementById('lsSubmit'); makeSel.onchange = () => { const make = state.vehicles.find(m => m.name === makeSel.value); lineSel.innerHTML = `` + (make ? [...make.model_lines].sort((a,b) => fixOrdinals(a.name).localeCompare(fixOrdinals(b.name))).map(l => ``).join('') : ''); lineSel.disabled = !make; modelSel.innerHTML = ``; modelSel.disabled = true; modSel.innerHTML = ``; modSel.disabled = true; submitBtn.disabled = true; }; lineSel.onchange = () => { const make = state.vehicles.find(m => m.name === makeSel.value); const line = make && make.model_lines.find(l => l.name === lineSel.value); modelSel.innerHTML = `` + (line ? [...line.models].sort((a,b) => fixOrdinals(a.name).localeCompare(fixOrdinals(b.name))).map(md => ``).join('') : ''); modelSel.disabled = !line; modSel.innerHTML = ``; modSel.disabled = true; submitBtn.disabled = true; }; modelSel.onchange = () => { const make = state.vehicles.find(m => m.name === makeSel.value); const line = make && make.model_lines.find(l => l.name === lineSel.value); const model = line && line.models.find(md => md.name === modelSel.value); modSel.innerHTML = `` + (model ? [...model.modifications].sort((a,b) => fixOrdinals(a.name).localeCompare(fixOrdinals(b.name))).map(md => ``).join('') : ''); modSel.disabled = !model; submitBtn.disabled = true; }; modSel.onchange = () => { submitBtn.disabled = !modSel.value; }; submitBtn.onclick = () => { const make = state.vehicles.find(m => m.name === makeSel.value); const line = make.model_lines.find(l => l.name === lineSel.value); const model = line.models.find(md => md.name === modelSel.value); const mod = model.modifications.find(md => md.id === modSel.value); saveVehicle({ makeName: make.name, modelLineName: line.name, modelName: model.name, mod }); location.hash = `#/vehicle/${mod.id}`; }; } /* ---------------- cart ---------------- */ function addToCart(productId, qty=1){ const existing = state.cart.find(i => i.id === productId); if(existing) existing.qty += qty; else state.cart.push({ id: productId, qty }); saveCart(); showToast('Added to cart'); } function removeFromCart(productId){ state.cart = state.cart.filter(i => i.id !== productId); saveCart(); route(); } function setQty(productId, qty){ const item = state.cart.find(i => i.id === productId); if(!item) return; item.qty = Math.max(1, qty); saveCart(); route(); } function updateCartCount(){ const count = state.cart.reduce((s,i) => s+i.qty, 0); document.getElementById('cartCount').textContent = count; } function cartTotal(){ return state.cart.reduce((s,i) => { const p = state.productsById[i.id]; return s + (p ? p.price*i.qty : 0); }, 0); } /* ---------------- product card / grid ---------------- */ function productCardHtml(p){ const disc = discountPct(p.price, p.mrp); const pCat = state.categoriesById[p.category_id]; return `
    ${disc ? `${disc}% OFF` : (p.badge ? `${esc(p.badge)}` : '')} ${esc(p.tag)} ${pCat ? `${categoryIcon(pCat.name)}` : ''} ${p.image ? `${esc(p.name)}` : productPlaceholder(p)}
    ${esc(p.brand)}
    ${esc(p.name)}
    ${fmtPrice(p.price)} ${p.mrp>p.price ? `${fmtPrice(p.mrp)}` : ''} ${disc ? `${disc}% off` : ''}
    `; } /* ---------------- routing ---------------- */ const app = document.getElementById('app'); function route(){ const hash = location.hash || '#/'; const parts = hash.replace(/^#\//,'').split('/').filter(Boolean); window.scrollTo(0,0); qsa('.cat-nav-inner a').forEach(a => a.classList.remove('active')); updateProductJsonLd(null); if(parts.length === 0) return renderHome(); if(parts[0] === 'category') return renderCategory(parts[1]); if(parts[0] === 'product') return renderProduct(parts[1]); if(parts[0] === 'search') return renderSearch(decodeURIComponent(parts[1]||'')); if(parts[0] === 'vehicle') return renderVehicleResults(parts[1]); if(parts[0] === 'cart') return renderCart(); if(parts[0] === 'checkout') return renderCheckout(); if(parts[0] === 'order-success') return renderOrderSuccess(); return renderHome(); } /* ---------------- home ---------------- */ function renderHome(){ setPageMeta(null, null); const top = topCategory(); const children = shoppableTopCategories(); const bestValue = [...state.products].sort((a,b)=> discountPct(b.price,b.mrp)-discountPct(a.price,a.mrp)).slice(0,10); const newest = state.products.slice(0,10); app.innerHTML = `

    Genuine brake parts, matched to your exact vehicle.

    Shop ${state.products.length}+ verified brake pads and components from trusted brands. Confirm fitment in seconds before you buy.

    Shop Brake Pads
    ${state.products.length}+Parts in catalog
    ${new Set(state.products.map(p=>p.brand)).size}Trusted brands
    ${Object.keys(state.compatByMod).length}+Vehicles supported

    Find parts for your vehicle

    Select your car to see guaranteed-fit parts only.

    Verified fitment — match by exact modification
    Trusted brands — Bosch, Brembo, Ferodo & more

    Shop by category

    ${children.map(c => `
    ${categoryIcon(c.name)}

    ${esc(c.name)}

    `).join('')}

    Best value deals

    View all →
    ${bestValue.map(productCardHtml).join('')}

    More brake pads

    View all →
    ${newest.map(productCardHtml).join('')}
    `; } function icon(paths){ return `${paths}`; } const ICON_CUT = 'var(--paper)'; const CAT_ICONS = { brake_pads: icon(` `), brake_disc: icon(` `), brake_drums: icon(` `), brake_calipers: icon(` `), brake_cylinder: icon(` `), vaccum_booster: icon(` `), repair_kits: icon(` `), abs_system: icon(` `), brake_electronic: icon(` `), brake_fluid: icon(` `), brake_hoses: icon(` `), hand_brakes: icon(` `), braking_system: icon(` `), default: icon(` `), }; function categoryIcon(name){ const n = (name || '').toLowerCase(); let key = 'default'; if(n.includes('pad')) key = 'brake_pads'; else if(n.includes('drum')) key = 'brake_drums'; else if(n.includes('disc') || n.includes('disk')) key = 'brake_disc'; else if(n.includes('caliper')) key = 'brake_calipers'; else if(n.includes('cylinder')) key = 'brake_cylinder'; else if(n.includes('vaccum') || n.includes('vacuum') || n.includes('booster')) key = 'vaccum_booster'; else if(n.includes('repair') || n.includes('kit')) key = 'repair_kits'; else if(n.includes('abs')) key = 'abs_system'; else if(n.includes('electronic')) key = 'brake_electronic'; else if(n.includes('fluid')) key = 'brake_fluid'; else if(n.includes('hose') || n.includes('line')) key = 'brake_hoses'; else if(n.includes('hand brake') || n.includes('handbrake')) key = 'hand_brakes'; else if(n.includes('braking system')) key = 'braking_system'; return CAT_ICONS[key] || CAT_ICONS.default; } /* ---------------- category listing ---------------- */ function renderCategory(catId){ const cat = state.categoriesById[catId]; if(!cat){ app.innerHTML = `

    Category not found.

    `; return; } // include this category + descendants const idsInScope = new Set([catId]); let frontier = [catId]; while(frontier.length){ const next = []; state.categories.forEach(c => { if(c.parent && frontier.includes(c.parent)){ idsInScope.add(c.id); next.push(c.id); } }); frontier = next; } let list = state.products.filter(p => idsInScope.has(p.category_id)); renderListingPage(cat.name, catBreadcrumb(catId), list, { subcats: catChildren(catId).filter(c => productCountInScope(c.id) > 0), activeCatId: catId, metaTitle: cat.seo_title || cat.name, metaDescription: cat.seo_desc, }); } function emptyStateHtml(hasFilters){ return `

    ${hasFilters ? 'No products match your selected filters.' : 'No products found here yet.'}

    ${hasFilters ? `` : ''} Browse All Categories
    `; } function renderListingPage(title, breadcrumbChain, list, opts={}){ setPageMeta(opts.metaTitle || title, opts.metaDescription || `Browse ${list.length} result${list.length!==1?'s':''} for ${title} at NoirParts — genuine, verified-fitment brake parts with fast dispatch.`); const brands = [...new Set(state.products.map(p=>p.brand))].sort(); const state_ = { brand: new Set(), axle: new Set(), sort: 'relevance' }; function apply(){ let out = list.filter(p => (state_.brand.size===0 || state_.brand.has(p.brand)) && (state_.axle.size===0 || state_.axle.has(p.tag)) ); if(state_.sort==='price-asc') out = [...out].sort((a,b)=>a.price-b.price); if(state_.sort==='price-desc') out = [...out].sort((a,b)=>b.price-a.price); if(state_.sort==='discount') out = [...out].sort((a,b)=>discountPct(b.price,b.mrp)-discountPct(a.price,a.mrp)); qs('#resultCount').textContent = `${out.length} product${out.length!==1?'s':''}`; qs('#gridTarget').innerHTML = out.length ? out.map(productCardHtml).join('') : emptyStateHtml(state_.brand.size>0 || state_.axle.size>0); } app.innerHTML = `
    ${opts.subcats && opts.subcats.length ? `
    ${opts.subcats.map(c => `
    ${categoryIcon(c.name)}

    ${esc(c.name)}

    `).join('')}
    ` : ''}

    ${esc(title)}

    `; qsa('input[data-brand]').forEach(cb => cb.onchange = () => { cb.checked ? state_.brand.add(cb.dataset.brand) : state_.brand.delete(cb.dataset.brand); apply(); }); qsa('input[data-axle]').forEach(cb => cb.onchange = () => { cb.checked ? state_.axle.add(cb.dataset.axle) : state_.axle.delete(cb.dataset.axle); apply(); }); qs('#sortSelect').onchange = (e) => { state_.sort = e.target.value; apply(); }; qs('#gridTarget').addEventListener('click', (e) => { if(e.target.id === 'clearFiltersBtn'){ state_.brand.clear(); state_.axle.clear(); qsa('input[data-brand]').forEach(cb => cb.checked = false); qsa('input[data-axle]').forEach(cb => cb.checked = false); apply(); } }); wireSidebarVehiclePicker(); apply(); } /* ---------------- search ---------------- */ function renderSearch(query){ const qRaw = (query||'').trim(); if(!qRaw){ renderListingPage(`Search results for ""`, [], []); return; } const qLower = qRaw.toLowerCase(); const STOPWORDS = new Set(['for','and','with','the','of','in','on','at','to','a','an','is','my','your','car','vehicle']); const qTokens = qLower.split(/[^a-z0-9]+/).filter(t => t.length >= 2 && !STOPWORDS.has(t)); // Generic part/category words should never count as a vehicle-name match (e.g. a Mercedes // "Shooting Brake" body style shouldn't hijack a search for "brake pads") — they're still kept // in qTokens so they narrow the product results once a vehicle (or no vehicle) is resolved. const PART_WORDS = new Set(['brake','brakes','pad','pads','disc','disk','discs','drum','drums','shoe','shoes', 'caliper','calipers','cylinder','cylinders','booster','boosters','kit','kits','fluid','fluids','hose','hoses', 'abs','electronic','repair','vaccum','vacuum','set','front','rear','universal','fit','genuine','spare','part','parts']); function matchesProductText(p, text){ if(!text) return true; if(p.name.toLowerCase().includes(text) || p.brand.toLowerCase().includes(text) || p.part_number.toLowerCase().includes(text) || p.id.toLowerCase().includes(text)) return true; const normText = text.replace(/[\s\-\.\/]/g, ''); return normText.length >= 3 && !!p.part_number && p.part_number.toLowerCase().replace(/[\s\-\.\/]/g, '').includes(normText); } function wordsOf(str){ return new Set((str||'').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)); } // Score candidate vehicle names (make / model-line / model) by how many query tokens they match // as WHOLE WORDS (not raw substrings — otherwise short words like "for" would false-positive match // inside names like "Forester"), so searches like "dzire brake pads" or "thar 1st generation" // resolve to compatible parts. Broader matches (make, then line) win by default; a more specific // match only overrides when it consumes strictly more query tokens. // Tie-break preference when two vehicle-name levels match the same number of tokens: prefer the // model LINE (e.g. "Dzire", "Baleno") over both the bare MAKE (too broad) and a specific MODEL/ // generation (too narrow — the user didn't actually ask for a generation, so don't guess one). const TIE_PRIORITY = { make: 0, model: 1, line: 2 }; let best = null; function consider(nameLc, modIds, label, level){ const nameWords = wordsOf(nameLc); const consumed = new Set(); qTokens.forEach(t => { if(!PART_WORDS.has(t) && nameWords.has(t)) consumed.add(t); }); if(!consumed.size) return; const score = consumed.size; const tiePriority = TIE_PRIORITY[level]; if(!best || score > best.score || (score === best.score && tiePriority > best.tiePriority)){ best = { score, tiePriority, modIds, label, consumedTokens: consumed }; } } state.vehicles.forEach(make => { const makeLc = make.name.toLowerCase(); const makeModIds = new Set(); make.model_lines.forEach(line => line.models.forEach(m2 => m2.modifications.forEach(md => makeModIds.add(md.id)))); consider(makeLc, makeModIds, properMake(make.name), 'make'); make.model_lines.forEach(line => { const lineLc = line.name.toLowerCase(); const lineModIds = new Set(); line.models.forEach(m2 => m2.modifications.forEach(md => lineModIds.add(md.id))); consider(lineLc, lineModIds, `${properMake(make.name)} ${fixOrdinals(line.name)}`, 'line'); line.models.forEach(model => { const modelLc = model.name.toLowerCase(); const modelModIds = new Set(model.modifications.map(md => md.id)); consider(modelLc, modelModIds, `${properMake(make.name)} ${fixOrdinals(model.name)}`, 'model'); }); }); }); let list, title, metaDescription; if(best){ const vehicleProductIds = new Set(); best.modIds.forEach(modId => (state.compatByMod[modId]||[]).forEach(pid => vehicleProductIds.add(pid))); let candidates = [...vehicleProductIds].map(id => state.productsById[id]).filter(Boolean); const remainderTokens = qTokens.filter(t => !best.consumedTokens.has(t)); if(remainderTokens.length){ const narrowed = candidates.filter(p => remainderTokens.every(t => matchesProductText(p, t))); if(narrowed.length) candidates = narrowed; } list = candidates; title = `Search results for "${qRaw}" — parts for ${best.label}`; metaDescription = `${list.length} genuine part${list.length!==1?'s':''} matched to ${best.label} at NoirParts. Verified fitment, fast dispatch, COD available.`; } else { list = state.products.filter(p => matchesProductText(p, qLower)); title = `Search results for "${qRaw}"`; metaDescription = `${list.length} result${list.length!==1?'s':''} for "${qRaw}" at NoirParts — genuine brake parts with verified fitment.`; } renderListingPage(title, [], list, { metaDescription }); } /* ---------------- vehicle results ---------------- */ function renderVehicleResults(modId){ const productIds = state.compatByMod[modId] || []; const list = productIds.map(id => state.productsById[id]).filter(Boolean); const v = state.selectedVehicle; const label = v && v.mod.id === modId ? `${properMake(v.makeName)} ${fixOrdinals(v.modelName)} — ${v.mod.name}` : 'Selected Vehicle'; renderListingPage(`Parts for ${label}`, [], list); // Insert a helpful banner above the grid const bar = document.querySelector('.page-title-bar .wrap'); if(bar){ const banner = document.createElement('div'); banner.className = 'fit-banner'; banner.style.marginTop = '10px'; banner.innerHTML = list.length ? `✓ Showing ${list.length} part(s) verified to fit your ${esc(label)}.` : `No parts found for this exact vehicle yet — try browsing all categories.`; if(!list.length) banner.classList.add('no'); bar.appendChild(banner); } } /* ---------------- product detail ---------------- */ function displayDescription(p){ const idx = p.description.indexOf('OEM-quality'); if(idx === -1) return p.description; return `Genuine ${p.brand} ${p.name}. ${p.description.slice(idx)}`; } function renderProduct(id){ const p = state.productsById[id]; if(!p){ app.innerHTML = `

    Product not found.

    `; return; } setPageMeta(`${p.name} — ${p.brand}`, `Buy ${p.name} by ${p.brand} at NoirParts. ${fmtPrice(p.price)}, verified fitment, fast dispatch, COD available.`); updateProductJsonLd({ "@context": "https://schema.org/", "@type": "Product", name: p.name, sku: p.id, ...(p.part_number ? { mpn: p.part_number } : {}), brand: { "@type": "Brand", name: p.brand }, description: p.description, offers: { "@type": "Offer", url: location.href, priceCurrency: "INR", price: String(p.price), availability: "https://schema.org/InStock", itemCondition: "https://schema.org/NewCondition" } }); const disc = discountPct(p.price, p.mrp); const chain = catBreadcrumb(p.category_id); const compatMods = state.compatByProduct[p.id] || []; const v = state.selectedVehicle; const fitsSelected = v && compatMods.includes(v.mod.id); const related = state.products.filter(x => x.category_id===p.category_id && x.id!==p.id).slice(0,6); app.innerHTML = `
    ${p.image ? `${esc(p.name)}` : productPlaceholder(p)}
    ${esc(p.brand)}

    ${esc(p.name)}

    Part Number: ${esc(p.part_number || '—')}  ·  Fitment: ${esc(p.tag)}
    ${v ? `
    ${fitsSelected ? `✓ Fits your ${esc(properMake(v.makeName))} ${esc(fixOrdinals(v.modelName))} (${esc(v.mod.name)})` : `⚠ Not confirmed to fit your selected ${esc(properMake(v.makeName))} ${esc(fixOrdinals(v.modelName))} — check compatibility list below`}
    ` : `
    Select your vehicle to confirm fitment. Choose vehicle
    `}
    ${fmtPrice(p.price)} ${p.mrp>p.price ? `${fmtPrice(p.mrp)}` : ''} ${disc ? `${disc}% off` : ''}
    Inclusive of all taxes

    Description

    ${esc(displayDescription(p))}

    ${Object.entries(p.specs).filter(([k]) => !['Address of Manufacturer','Marketed by'].includes(k)).length ? `

    Specifications

    ${Object.entries(p.specs).filter(([k]) => !['Address of Manufacturer','Marketed by'].includes(k)).map(([k,val]) => ``).join('')}
    ${esc(k)}${esc(val)}
    ` : ''}

    Compatible Vehicles (${compatMods.length})

    ${compatMods.slice(0,60).map(modId => { const row = findVehicleByModId(modId); return row ? `
    ${esc(properMake(row.make))} ${esc(fixOrdinals(row.model))}${esc(row.mod.name)} · ${row.mod.start||''}-${row.mod.end||''}
    ` : ''; }).join('') || '
    Compatibility data not available.
    '} ${compatMods.length>60 ? `
    + ${compatMods.length-60} more vehicles
    ` : ''}
    ${related.length ? `

    You may also need

    ${related.map(productCardHtml).join('')}
    ` : ''}
    `; } function stepQty(delta){ const el = document.getElementById('pdQty'); el.value = Math.max(1, parseInt(el.value||'1') + delta); } function findVehicleByModId(modId){ for(const make of state.vehicles){ for(const line of make.model_lines){ for(const model of line.models){ const mod = model.modifications.find(m => m.id === modId); if(mod) return { make: make.name, model: model.name, mod }; } } } return null; } /* ---------------- cart page ---------------- */ async function goToRealCheckout(btn){ if(btn){ btn.disabled = true; btn.textContent = 'Preparing checkout…'; } try { for(const item of state.cart){ const p = state.productsById[item.id]; if(!p || !p.odoo_product_id || !p.odoo_template_id) continue; const res = await fetch('/shop/cart/add', { method: 'POST', credentials: 'same-origin', headers: {'Content-Type':'application/json'}, body: JSON.stringify({jsonrpc:'2.0', method:'call', params:{ product_id: p.odoo_product_id, product_template_id: p.odoo_template_id, quantity: item.qty }}) }); const j = await res.json(); if(j.error) throw new Error((j.error.data && j.error.data.message) || 'Could not add ' + p.name + ' to cart'); } window.location.href = '/shop/cart'; } catch(e){ if(btn){ btn.disabled = false; btn.textContent = 'Proceed to Checkout'; } alert('Sorry, something went wrong preparing your order. Please try again.\n' + (e.message||'')); } } function renderCart(){ setPageMeta('Your Cart', 'Review the items in your NoirParts shopping cart before checkout.'); if(!state.cart.length){ app.innerHTML = `

    Your cart is empty

    Browse our catalog and add genuine brake parts to your cart.

    Start Shopping
    `; return; } const rows = state.cart.map(item => { const p = state.productsById[item.id]; if(!p) return ''; return `
    ${p.image ? `` : `
    `}
    ${esc(p.name)}
    ${esc(p.brand)}
    ${fmtPrice(p.price)}
    ${fmtPrice(p.price*item.qty)} Remove `; }).join(''); const total = cartTotal(); const shipping = total >= 1500 ? 0 : 99; app.innerHTML = `

    Shopping Cart

    ${rows}
    ProductPriceQuantitySubtotal

    Order Summary

    Subtotal${fmtPrice(total)}
    Shipping${shipping===0?'FREE':fmtPrice(shipping)}
    Total${fmtPrice(total+shipping)}
    Continue shopping
    `; } /* ---------------- checkout (UI only — no real payment is processed) ---------------- */ function renderCheckout(){ if(!state.cart.length){ location.hash = '#/cart'; return; } setPageMeta('Checkout', 'Secure checkout for your NoirParts order — COD available across India.'); const total = cartTotal(); const shipping = total >= 1500 ? 0 : 99; app.innerHTML = `

    Checkout

    Shipping Details

    Payment Method

    Order Summary

    ${state.cart.map(i => { const p = state.productsById[i.id]; if(!p) return ''; return `
    ${esc(p.name)} × ${i.qty}${fmtPrice(p.price*i.qty)}
    `; }).join('')}
    Shipping${shipping===0?'FREE':fmtPrice(shipping)}
    Total${fmtPrice(total+shipping)}
    `; document.getElementById('checkoutForm').onsubmit = (e) => { e.preventDefault(); // Demo storefront: no real payment gateway is connected — this simulates order placement only. state.cart = []; saveCart(); location.hash = '#/order-success'; }; } function renderOrderSuccess(){ setPageMeta('Order Confirmed', 'Your NoirParts order has been placed successfully.'); app.innerHTML = `

    Order placed successfully!

    Thank you for shopping with Noirparts. This is a demo storefront — no payment has actually been charged. A confirmation would normally be sent to your email.

    Continue Shopping
    `; } /* ---------------- search form wiring ---------------- */ document.getElementById('searchForm').addEventListener('submit', (e) => { e.preventDefault(); const q = document.getElementById('searchInput').value.trim(); if(q) location.hash = `#/search/${encodeURIComponent(q)}`; }); /* ---------------- search suggestions ---------------- */ (function(){ const searchForm = document.getElementById('searchForm'); if(!searchForm) return; const searchInput = document.getElementById('searchInput'); const wrap = document.createElement('div'); wrap.className = 'search-wrap'; searchForm.parentNode.insertBefore(wrap, searchForm); wrap.appendChild(searchForm); const box = document.createElement('div'); box.id = 'searchSuggest'; box.className = 'search-suggest'; wrap.appendChild(box); const SS_STOPWORDS = new Set(['for','and','with','the','of','in','on','at','to','a','an','is','my','your','car','vehicle']); const SS_PART_WORDS = new Set(['brake','brakes','pad','pads','disc','disk','discs','drum','drums','shoe','shoes', 'caliper','calipers','cylinder','cylinders','booster','boosters','kit','kits','fluid','fluids','hose','hoses', 'abs','electronic','repair','vaccum','vacuum','set','front','rear','universal','fit','genuine','spare','part','parts']); const SS_TIE_PRIORITY = { make: 0, model: 1, line: 2 }; let categoriesWithProducts = null; function ssCategoriesWithProducts(){ if(categoriesWithProducts) return categoriesWithProducts; categoriesWithProducts = new Set(); (state.products||[]).forEach(p => { if(p.category_id) categoriesWithProducts.add(p.category_id); }); return categoriesWithProducts; } function ssWordsOf(str){ return new Set((str||'').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)); } function ssMatchesProductText(p, text){ if(!text) return true; if(p.name.toLowerCase().includes(text) || p.brand.toLowerCase().includes(text) || p.part_number.toLowerCase().includes(text) || p.id.toLowerCase().includes(text)) return true; const normText = text.replace(/[\s\-\.\/]/g, ''); return normText.length >= 3 && !!p.part_number && p.part_number.toLowerCase().replace(/[\s\-\.\/]/g, '').includes(normText); } function ssFindVehicle(qTokens){ let best = null; function consider(nameLc, modIds, label, level){ const nameWords = ssWordsOf(nameLc); const consumed = new Set(); qTokens.forEach(t => { if(!SS_PART_WORDS.has(t) && nameWords.has(t)) consumed.add(t); }); if(!consumed.size) return; const score = consumed.size; const tiePriority = SS_TIE_PRIORITY[level]; if(!best || score > best.score || (score === best.score && tiePriority > best.tiePriority)){ best = { score, tiePriority, modIds, label, consumedTokens: consumed }; } } state.vehicles.forEach(make => { const makeLc = make.name.toLowerCase(); const makeModIds = new Set(); make.model_lines.forEach(line => line.models.forEach(m2 => m2.modifications.forEach(md => makeModIds.add(md.id)))); consider(makeLc, makeModIds, properMake(make.name), 'make'); make.model_lines.forEach(line => { const lineLc = line.name.toLowerCase(); const lineModIds = new Set(); line.models.forEach(m2 => m2.modifications.forEach(md => lineModIds.add(md.id))); consider(lineLc, lineModIds, `${properMake(make.name)} ${fixOrdinals(line.name)}`, 'line'); line.models.forEach(model => { const modelLc = model.name.toLowerCase(); const modelModIds = new Set(model.modifications.map(md => md.id)); consider(modelLc, modelModIds, `${properMake(make.name)} ${fixOrdinals(model.name)}`, 'model'); }); }); }); return best; } function ssGetResults(qRaw){ const qLower = qRaw.toLowerCase(); const qTokens = qLower.split(/[^a-z0-9]+/).filter(t => t.length >= 2 && !SS_STOPWORDS.has(t)); const vehicle = ssFindVehicle(qTokens); let candidates; if(vehicle){ const vehicleProductIds = new Set(); vehicle.modIds.forEach(modId => (state.compatByMod[modId]||[]).forEach(pid => vehicleProductIds.add(pid))); candidates = [...vehicleProductIds].map(id => state.productsById[id]).filter(Boolean); const remainderTokens = qTokens.filter(t => !vehicle.consumedTokens.has(t)); if(remainderTokens.length){ const narrowed = candidates.filter(p => remainderTokens.every(t => ssMatchesProductText(p, t))); if(narrowed.length) candidates = narrowed; } } else { candidates = state.products.filter(p => ssMatchesProductText(p, qLower)); } const withProducts = ssCategoriesWithProducts(); const categories = Object.values(state.categoriesById || {}).filter(c => { if(!c.leaf || !withProducts.has(c.id)) return false; const cWords = ssWordsOf(c.name); return qTokens.some(t => cWords.has(t)); }).slice(0, 2); return { vehicle, candidates, categories }; } function ssProductThumb(p){ const cat = state.categoriesById && state.categoriesById[p.category_id]; const icon = cat ? categoryIcon(cat.name) : CAT_ICONS.default; return `${icon}`; } function ssRender(qRaw){ const { vehicle, candidates, categories } = ssGetResults(qRaw); let html = ''; if(vehicle){ html += `
    Parts for ${esc(vehicle.label)} — ${candidates.length} found
    `; } if(categories.length){ html += ``; categories.forEach(c => { html += `
    ${categoryIcon(c.name)} ${esc(c.name)}
    `; }); } const top = candidates.slice(0, 5); if(top.length){ html += ``; top.forEach(p => { html += `
    ${ssProductThumb(p)} ${esc(p.name)} ${fmtPrice(p.price)}
    `; }); } if(!vehicle && !top.length && !categories.length){ html += `
    No matches yet — press Enter to search all products.
    `; } if(candidates.length > top.length || vehicle){ html += `View all${candidates.length ? ' '+candidates.length : ''} results for "${esc(qRaw)}"`; } box.innerHTML = html; box.querySelectorAll('[data-hash]').forEach(el => { el.addEventListener('mousedown', (e) => { e.preventDefault(); location.hash = el.getAttribute('data-hash'); ssHide(); }); }); } function ssShow(){ box.classList.add('open'); } function ssHide(){ box.classList.remove('open'); } let ssTimer = null; searchInput.addEventListener('input', () => { clearTimeout(ssTimer); const q = searchInput.value.trim(); if(q.length < 2){ ssHide(); return; } ssTimer = setTimeout(() => { ssRender(q); ssShow(); }, 200); }); searchInput.addEventListener('focus', () => { const q = searchInput.value.trim(); if(q.length >= 2){ ssRender(q); ssShow(); } }); searchInput.addEventListener('blur', () => { setTimeout(ssHide, 150); }); searchInput.addEventListener('keydown', (e) => { if(e.key === 'Escape') ssHide(); }); })(); wireVehiclePicker(); boot(); /* cache-bust-fix */